import {
  _read,
  action,
  addChangeHook,
  assert,
  atom,
  type AtomLike,
  computed,
  context,
  isAction,
  isAtom,
  isLinkedListAtom,
  isObject,
  isWritableAtom,
  type LinkedList,
  LL_NEXT,
  type LLNode,
  noop,
  peek,
  ReatomError,
  type Rec,
  type Unsubscribe,
  wrap,
} from '@reatom/core'

import type { AttributesAtomMaybe, JSX, LinkedListJSXAtom } from './jsx'
import { reatomClassName } from './utils'

declare type JSXElement = JSX.Element

export type FC<Props = {}> = (
  props: Props & { children?: JSXElement },
) => JSXElement

export type { JSX, JSXElement }

export { reatomClassName } from './utils'

type DomApis = Pick<
  typeof window,
  | 'document'
  | 'Node'
  | 'Text'
  | 'Element'
  | 'MutationObserver'
  | 'HTMLElement'
  | 'DocumentFragment'
>

export let DOM = atom(globalThis.window, '_jsx.DOM')

export let DEBUG = atom(true, '_jsx.DEBUG')

let stylesCount = 0
let styles: Rec<string> = {}
/**
 * @note Create style tag for support oldest browser.
 * @see https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
 * @see https://measurethat.net/Benchmarks/Show/5920
 */
export let stylesheet = atom(() => DOM().document.head.appendChild(DOM().document.createElement('style')).sheet!, 'jsx.stylesheet')
let name = ''
let named = (element: Node, key: string) => `${name}.${element.nodeName.toLowerCase()}._${key}`

interface Meta {
  subscribes: (() => Unsubscribe)[]
  unsubscribes: Unsubscribe[]
  mount: ((element: Node) => ((element: Node) => void) | undefined) | undefined
  unmount: ((element: Node) => void) | undefined
}
let metaSymbol = atom(() => Symbol())
let ensureMeta = (node: Node): Meta => {
  return ((node as any)[metaSymbol()] ??= {
    subscribes: [],
    unsubscribes: [],
    mount: undefined,
    unmount: undefined,
  })
}
let unlink = (node: Node, subscribe: () => () => void) => {
  let meta = ensureMeta(node)
  meta.subscribes.push(subscribe)
  if (node.isConnected) meta.unsubscribes.push(subscribe())
}

/**
 * @see https://github.com/preactjs/preact/blob/d16a34e275e31afd6738a9f82b5ba2fb9dbf032b/src/diff/props.js#L107
 * @see https://www.measurethat.net/Benchmarks/Show/7818
 */
let propertiesAsAttributes = new Set([
  /**
   * Numeric attributes with a default value other than 0.
   */
  'height',
  'high',
  'low',
  'optimum',
  'results',
  'size',
  'span',
  'start',
  'width',

  /**
   * Numeric properties with a default value other than 0.
   */
  // 'colspan',
  // 'rowspan',
  // 'maxlength',
  // 'minlength',
  // 'tabindex',

  /**
   * Properties with value HTMLElement.
   */
  'form',
  'list',

  /**
   * Setting the value to an empty string must be explicit.
   */
  'download',
  'href',
  'role',
])
/**
 * @see https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
 */
let booleanAttributes = new Set([
  'allowfullscreen',
  'allowpaymentrequest',
  'async',
  'attributionsrc',
  'autofocus',
  'autoplay',
  'browsingtopics',
  'capture',
  'checked',
  'compact',
  'controls',
  'credentialless',
  'crossorigin',
  'declare',
  'default',
  'defer',
  'disabled',
  'disablepictureinpicture',
  'disableremoteplayback',
  'formnovalidate',
  'hidden',
  'inert',
  'ismap',
  'itemscope',
  'loop',
  'multiple',
  'muted',
  'nomodule',
  'novalidate',
  'open',
  'playsinline',
  'readonly',
  'required',
  'reversed',
  'scoped',
  'selected',
  'shadowrootclonable',
  'shadowrootdelegatesfocus',
  'shadowrootserializable',
  'virtualkeyboardpolicy',
  'webkitdirectory',
])

let isSkipped = (value: unknown): value is boolean | '' | null | undefined =>
  typeof value === 'boolean' || value === '' || value == null

/**
 * @todo Explore adding elements to a DocumentFragment before adding them to a Document.
 * @see https://www.measurethat.net/Benchmarks/Show/13274
 */
let walk = (
  dom: DomApis,
  element: JSX.Element | DocumentFragment,
  children: JSX.DOMAttributes<JSX.Element>['children'],
) => {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; i++) walk(dom, element, children[i])
  } else if (isLinkedListAtom(children)) {
    walkLinkedList(dom, element as JSX.Element, children as any)
  } else if (isAtom(children)) {
    element.append(walkAtom(dom, children as AtomLike<JSX.ElementChildren>))
  } else if (typeof children === 'function') {
    walk(dom, element, computed(children as () => any))
  } else if (!isSkipped(children)) {
    element.append(children as Node | string)
  }
}

let walkAtom = (
  dom: DomApis,
  anAtom: AtomLike<JSX.ElementChildren>,
): DocumentFragment => {
  let fragment = createLiveFragment(dom, anAtom.name)

  unlink(
    fragment.__reatomFragment.start,
    () => anAtom.subscribe(fragment.__reatomFragment.update),
  )

  return fragment
}

let walkLinkedList = (
  dom: DomApis,
  element: JSX.Element,
  list: LinkedListJSXAtom,
) => {
  let lastVersion = -1

  let cb = (state: LinkedList<LLNode<JSX.Element>>) => {
    if (state.version - 1 > lastVersion) {
      element.innerHTML = ''
      for (let { head } = state; head; head = head[LL_NEXT]) {
        throwNativeFragment(head)
        element.append(head)
      }
    } else {
      let appendBatch: undefined | DocumentFragment
      for (let change of state.changes) {
        if (change.kind === 'create') {
          throwNativeFragment(change.node)

          appendBatch ??= dom.document.createDocumentFragment()

          appendBatch.append(change.node)
        } else if (change.kind === 'createMany') {
          appendBatch ??= dom.document.createDocumentFragment()

          for (let node of change.nodes) {
            throwNativeFragment(node)
            appendBatch.append(node)
          }
        } else if (appendBatch) {
          element.append(appendBatch)
          appendBatch = undefined
        }

        if (change.kind === 'remove') {
          if (isLiveFragment(change.node)) {
            let fragment = change.node.__reatomFragment
            fragment.update()
            fragment.start.remove()
            fragment.end.remove()
          } else {
            element.removeChild(change.node)
          }
        } else if (change.kind === 'removeMany') {
          for (let node of change.nodes) {
            if (isLiveFragment(node)) {
              let fragment = node.__reatomFragment
              fragment.update()
              fragment.start.remove()
              fragment.end.remove()
            } else {
              element.removeChild(node)
            }
          }
        }
        // TODO support fragments
        else if (change.kind === 'swap') {
          let [aNext, bNext] = [change.a.nextSibling, change.b.nextSibling]
          if (bNext) {
            element.insertBefore(change.a, bNext)
          } else {
            element.append(change.a)
          }

          if (aNext) {
            element.insertBefore(change.b, aNext)
          } else {
            element.append(change.b)
          }
        }
        // TODO support fragments
        else if (change.kind === 'move') {
          if (change.after) {
            change.after.insertAdjacentElement('afterend', change.node)
          } else {
            element.append(change.node)
          }
        } else if (change.kind === 'clear') {
          element.innerHTML = ''
        }
      }

      if (appendBatch) element.append(appendBatch)
    }
    lastVersion = state.version
  }

  unlink(element, () => {
    // it's critical to not use not a last state, but the each state.
    let unSubscribe = list.subscribe(noop)
    let rootFrame = context()
    let unChange = addChangeHook(list, (state) => {
      if (rootFrame === context()) cb(state as LinkedList<LLNode<JSX.Element>>)
    })

    return () => {
      unSubscribe()
      unChange()
    }
  })

  let state = list()
  // check if change hook wasn't called by initialization
  if (lastVersion === -1) cb(state as LinkedList<LLNode<JSX.Element>>)
}

interface LiveDocumentFragment extends DocumentFragment {
  __reatomFragment: {
    start: Comment
    end: Comment
    update: (children?: JSX.ElementChildren) => void
  }
}

let isLiveFragment = (node: Node): node is LiveDocumentFragment =>
  !!node && '__reatomFragment' in node

let throwNativeFragment = (element: JSX.Element) => {
  assert(
    // TODO improve perf
    String(element) !== '[object DocumentFragment]' ||
      '__reatomFragment' in element,
    'native fragment is not supported',
    ReatomError,
  )
}

let createLiveFragment = (dom: DomApis, name: string): LiveDocumentFragment => {
  let fragment = dom.document.createDocumentFragment() as LiveDocumentFragment
  let start = dom.document.createComment(name)
  let end = start.cloneNode() as Comment
  let update = (children?: JSX.ElementChildren) => {
    while (start.nextSibling && start.nextSibling !== end) {
      start.nextSibling.remove()
    }

    walk(dom, fragment, children)
    start.after(fragment)
  }
  fragment.__reatomFragment = {
    start,
    end,
    update,
  }
  fragment.append(start, end)
  return fragment
}

let setProps = (dom: DomApis, element: JSX.Element, props: Rec) => {
  for (let key in props) {
    let value = props[key]
    setProp(dom, element, key, value)
  }
}

let setProp = (dom: DomApis, element: JSX.Element, key: string, value: any) => {
  if (key === 'children' || key === 'element') return

  /**
   * @todo Show warning if isAtom(value) && !isAction(value).
   * @todo Convert to named action.
   */
  if (key === 'ref') {
    ensureMeta(element).mount = () => value(element)
    return
  }

  /**
   * @todo Show warning if isAtom(value) && !isAction(value).
   * @todo Remove previous event listener.
   */
  if (key.startsWith('on:')) {
    key = key.slice(3)
    if (typeof value === 'function') {
      element.addEventListener(
        key,
        wrap(
          // only for logging purposes
          action(value as () => void, named(element, key)),
        ),
      )
    }
    return
  }

  /**
   * @todo Show warning if isAction(value).
   * @todo Revert previous value.
   */
  if (key === '$spread') {
    let spread = (val: any) => setProps(dom, element, val)
    if (isAtom(value) && !isAction(value)) {
      unlink(element, () => value.subscribe(spread))
    } else if (typeof value === 'function') {
      unlink(element, () => computed(value, named(element, key)).subscribe(spread))
    } else {
      spread(value)
    }
    return
  }

  let setter = (val: any) => set(dom, element, key, val)

  /** @todo Show warning if isAction(value). */
  if (key === 'class' || key === 'className') {
    if (typeof value === 'object' || typeof value === 'function') {
      unlink(element, () => reatomClassName(value).subscribe(setter))
    } else {
      setter(typeof value === 'string' ? value : undefined)
    }
    return
  }

  if (key.startsWith('model:')) {
    key = key.slice(6) as 'checked' | 'value' | 'valueAsDate' | 'valueAsNumber'
    if (isWritableAtom(value)) {
      setProp(dom, element, 'on:input', (event: any) => {
        if (!event.target.validity.badInput) {
          let val = event.target[key]
          value.set(val == null || Number.isNaN(val) ? undefined : val)
        }
      })
    }
  }

  /** @todo Show warning if isAction(value). */
  if (isAtom(value) && !isAction(value)) {
    unlink(element, () => value.subscribe(setter))
  } else if (typeof value === 'function') {
    unlink(element, () => computed(value, named(element, key)).subscribe(setter))
  } else {
    setter(value)
  }
}

let set = (dom: DomApis, element: JSX.Element, key: string, value: any) => {
  if (key.startsWith('css:')) {
    setStyleProp(
      element.style,
      '--' + key.slice(4),
      value == null ? value : String(value),
    )
  } else if (key === 'css') {
    /** @todo Should support record? */
    let styleId = styles[value]
    if (!styleId) {
      styleId = styles[value] = '' + ++stylesCount
      stylesheet().insertRule(`[data-reatom-style="${styleId}"]{${value}}`)
    }

    /** @see https://measurethat.net/Benchmarks/Show/11819 */
    element.setAttribute('data-reatom-style', styleId)
  } else if (key === 'style') {
    if (isObject(value)) {
      for (let key in value) setStyleProp(element.style, key, value[key])
    } else {
      for (let key in element.style) element.style.removeProperty(key)
    }
  } else if (key.startsWith('style:')) {
    setStyleProp(element.style, key.slice(6), value)
  } else if (key.startsWith('prop:')) {
    // @ts-expect-error
    element[key.slice(5)] = value
  } else if (
    !propertiesAsAttributes.has(key) &&
    element instanceof dom.HTMLElement &&
    (key in element || key === 'class')
  ) {
    /**
     * @see https://measurethat.net/Benchmarks/Show/54
     * @see https://measurethat.net/Benchmarks/Show/31249
     */
    if (key === 'class') key = 'className'
    /** @note element.valueAsNumber = '' // element.value === '0' */
    else if (key === 'valueAsNumber') key = 'value'

    /** @note element.valueAsDate = '' // Uncaught TypeError: Failed to convert value to 'object'. */
    // @ts-ignore
    element[key] = value == null && key !== 'valueAsDate' ? '' : value
  } else {
    if (key === 'className') key = 'class'
    else if (key.startsWith('attr:')) key = key.slice(5)

    /**
     * @note aria- and data- attributes have no boolean representation.
     * A `false` value is different from the attribute not being
     * present, so we can't remove it. For non-boolean aria
     * attributes we could treat false as a removal, but the
     * amount of exceptions would cost too many bytes. On top of
     * that other frameworks generally stringify `false`.
     */
    let isBool = booleanAttributes.has(key)
    if (value == null || (isBool && value === false)) element.removeAttribute(key)
    else element.setAttribute(key, isBool && value === true ? '' : value)
  }
}

let setStyleProp = (
  style: CSSStyleDeclaration,
  key: string,
  value: any,
): void => {
  if (value == null) style.removeProperty(key)
  else style.setProperty(key, value)
}

export let h = (tag: any, props: Rec, ...children: any[]): JSX.Element => {
  let dom = _read(DOM)?.state ?? peek(DOM)

  if (isAtom(tag)) {
    // FIXME we need types refactoring
    return walkAtom(dom, tag) as any
  }

  if (tag === hf) {
    // needed for `walkLinkedList`
    let fragment = createLiveFragment(dom, '')
    walk(dom, fragment, children)
    fragment.append(fragment.__reatomFragment.end)
    // FIXME we need types refactoring
    return fragment as any
  }

  props ??= {}

  let element: JSX.Element

  if (typeof tag === 'function') {
    if (children.length) {
      props.children = children
    }

    if (tag === Bind) {
      element = props.element
      props.element = undefined
    } else {
      let _name = name
      try {
        name = tag.name
        return tag(props)
      } finally {
        name = _name
      }
    }
  } else {
    element = tag.startsWith('svg:')
      ? dom.document.createElementNS('http://www.w3.org/2000/svg', tag.slice(4))
      : dom.document.createElement(tag)

    // For debug
    if (name && peek(DEBUG)) element.setAttribute('data-reatom-name', name)
  }

  if ('children' in props) children = props.children

  setProps(dom, element, props)

  walk(dom, element, children)

  return element
}

/**
 * Fragment.
 * @todo Describe a function as a component.
 */
export let hf = () => {}

export let mount = (target: Element, child: Element): void => {
  let dom = DOM()
  let symbol = metaSymbol()

  /**
   * @note The moved node creates two mutations: deletion then addition.
   * @todo Moving an node in the DOM unsubscribes and resubscribes to atoms.
   * @todo Call `observer.disconnect()` after unmounting the application.
   */
  let observer = new dom.MutationObserver(
    wrap((mutationsList) => {
      for (let mutation of mutationsList) {
        mutation.addedNodes.forEach((addedNode) => {
          let iterator = dom.document.createNodeIterator(addedNode, 1 | 128)
          while (iterator.nextNode()) {
            let meta = (iterator.referenceNode as any)[symbol] as Meta | undefined
            meta?.subscribes.forEach((subscribe) => meta.unsubscribes.push(subscribe()))
          }
          while (iterator.previousNode()) {
            let meta = (iterator.referenceNode as any)[symbol] as Meta | undefined
            if (meta) {
              let unmount = meta.mount?.(iterator.referenceNode)
              if (typeof unmount === 'function') meta.unmount = unmount
            }
          }
        })
        mutation.removedNodes.forEach((removedNode) => {
          let iterator = dom.document.createNodeIterator(removedNode, 1 | 128)
          while (iterator.nextNode()) {
            let meta = (iterator.referenceNode as any)[symbol] as Meta | undefined
            if (meta) {
              if (meta.unsubscribes.length > 0) {
                meta.unsubscribes.forEach((unsubscribe) => unsubscribe())
                meta.unsubscribes = []
              }
              meta.unmount?.(iterator.referenceNode)
            }
          }
        })
      }
    }),
  )
  observer.observe(target.parentElement!, {
    childList: true,
    subtree: true,
  })

  // TODO fix
  // target.append(...[child].flat(Infinity))
  target.append(child)
}

/**
 * This simple utility needed only for syntax highlighting and it just concatenates all passed strings.
 * Falsy values are ignored, except for `0`.
 */
export let css = (strings: TemplateStringsArray, ...values: any[]) => {
  let result = ''
  for (let i = 0; i < strings.length; i++) {
    result += strings[i] + (values[i] || values[i] === 0 ? values[i] : '')
  }
  return result
}

export let Bind = <T extends Element>(
  props: { element: T } & AttributesAtomMaybe<
    Partial<Omit<T, 'children'>> & JSX.DOMAttributes<T>
  >,
): T => props.element
